Skip to content

Make values()/items() honour dol wrapper transforms - #10

Merged
thorwhalen merged 1 commit into
masterfrom
claude/mongodol-bulk-views
Aug 4, 2026
Merged

Make values()/items() honour dol wrapper transforms#10
thorwhalen merged 1 commit into
masterfrom
claude/mongodol-bulk-views

Conversation

@thorwhalen

Copy link
Copy Markdown
Member

Closes #7.

The bug, reproduced

Against a live local MongoDB:

s  = MongoCollectionFirstDocPersister('test/scrap')
s[{'_id': '123'}] = {'name': 'Matthew', 'age': 42}
s[{'_id': '456'}] = {'name': 'Mark', 'age': 43}

ss = wrap_kvs(s, obj_of_data=itemgetter('name', 'age'))   # plain dol.wrap_kvs

[ss[k] for k in ss]   # [('Matthew', 42), ('Mark', 43)]     correct
list(ss.values())     # [{'_id': '123', ...}, {...}]        raw documents

list(ss.values()) != [ss[k] for k in ss] is a Mapping-contract violation, and
a silent one: any code that iterates values rather than keys gets wrong-typed
data with no error. Containment was worse — ('Matthew', 42) in ss.values()
handed a tuple to find() as a filter and raised OperationFailure.

Why it happened

MongoCollectionReader implements a bulk-read protocoliter_values,
iter_items, contains_value, contains_item — so a whole values() view is
one find instead of N. Its views called self._mapping.iter_values().

A dol Store wrapper forwards every attribute it does not define to the store
it wraps. So that one call punched straight through the wrapper chain to the raw
mongo store, skipping every transform on the way. The efficiency win and the
transform were mutually exclusive, and the wrapper silently won.

MongoBaseStore (+ mongodol.trans.wrap_kvs) was the existing workaround, but
it only works if the user knows to reach for mongodol's wrap_kvs instead of
dol's — the leak the issue is about.

The fix — resolve the bulk path, don't delegate to it

New module mongodol/views.py. Instead of trusting attribute delegation, the
views walk the wrapper chain inward, remembering each layer they cross, until
they reach a store that really implements the bulk-read protocol. The bulk stream
is then re-transformed by the crossed layers, innermost first, so it lands in the
same space as store[k].

A layer may be crossed only when its read path is plain transform composition
Store's own __getitem__/__iter__ — because then its contribution is exactly
_key_of_id/_obj_of_data, which map cleanly over a stream. A layer that
redefines __getitem__ (wrap_kvs(postget=...)) or __iter__ (filt_iter,
cached_keys) cannot be expressed that way, so the resolver refuses to guess:
NoBulkReadPath, and the view falls back to the generic per-key path. Correct,
just one round trip per key. Correctness first, efficiency only when provable.

Containment follows the same rule in the ingoing direction and additionally
requires an inverse: a layer with an obj_of_data but no data_of_obj cannot
push a user-space value down to mongo, so it falls back to a scan rather than
sending nonsense to find().

Design notes:

  • All local to mongodol. dol is untouched — the fix lives where the bulk
    protocol lives.
  • Open-closed: disable_bulk_read is the documented opt-out for a class that
    inherits bulk-read methods no longer matching its own __getitem__.
  • MongoBaseStore and mongodol.trans.wrap_kvs keep working unchanged from the
    outside; their bulk methods now route through the resolver, so they compose
    with plain wrap_kvs layers too.
  • Nothing in views.py is mongo-specific. It is a general answer to "how does a
    store with a bulk-read fast path compose with dol wrappers?" and is a
    reasonable candidate for dol to own one day.

Drive-by fixes (surfaced by the new invariant tests)

MongoCollectionMultipleDocsReader / ...Persister were entirely non-functional:

  • __getitem__ always raised — an obj_of_data-shaped function was passed as a
    postget. Added the key-aware PostGet.all_docs_fetch.
  • __setitem__ always raised — a stale _mgc attribute, and a Mapping treated
    as a collection of docs (a Mapping is a Collection, so a single doc got
    "iterated" into its field names).

They are also declared bulk-unfaithful, since their values are lists of docs
while the inherited bulk stream yields single docs.

Removed mongodol/tests/not_working.py: an uncollected TDD placeholder for
exactly this issue, superseded by mongodol/tests/views_test.py.

Known gap, deliberately left

With no getitem_projection, iter_items pops the key fields out of the value,
so items() values lack _id while store[k] has it. Fixing that changes
results tests/int_tests/base_int_test.py::test_store_with_mappers explicitly
asserts, so it needs a call rather than a unilateral rewrite. Filed as #9 and
pinned here with a strict=True xfail, which will flip the suite red the moment
it is fixed.

Tests

mongodol/tests/views_test.py, 12 tests + 1 strict xfail, pinning

list(store.values()) == [store[k] for k in store]
list(store.items())  == [(k, store[k]) for k in store]

across: plain wrap_kvs value and key transforms, stacked wrappers, the
MongoBaseStore route, the two fallback routes (filt_iter, user postget),
the multiple-docs store, both containment directions, and the resolver's own
contract (including that hasattr lies on a Store but provides_bulk_read
does not — the delegation trap that made the bug silent).

Red first, verified: with the source reverted to master and only the new
test file in place, 8 of the 13 fail, including the issue reproduction. With the
fix, all pass.

master this branch
pytest --doctest-modules 31 passed 44 passed, 1 xfailed
pytest 19 passed 31 passed, 1 xfailed

Ten consecutive runs of each, green both ways.

Dependents (all against a live local MongoDB): py2store 8 passed,
funds 1 passed, invest / peruse / qo no tests collected, know
1 failed / 3 passed — that failure is a zipfile.BadZipFile on a test fixture,
identical on master, unrelated.

https://claude.ai/code/session_01Kug7UUbVeCQgruvNXUq63c

A mongo store serves its whole (key, value) stream in one `find`, so
MongoCollectionReader implements a bulk-read protocol (iter_values,
iter_items, contains_value, contains_item) and its views use it.

That fast path did not compose. A dol Store wrapper forwards any attribute
it does not define to the store it wraps, so a view calling
`self._mapping.iter_values()` punched straight through every wrapper and
yielded raw documents -- silently skipping the value transforms the user
asked for. `wrap_kvs(s, obj_of_data=f)` gave a correct `ss[k]` and a wrong
`list(ss.values())`: a Mapping-contract violation producing wrong-typed data
for anyone iterating values rather than keys.

New module `mongodol/views.py` resolves the bulk stream explicitly instead of
relying on attribute delegation. It walks the wrapper chain inward, crossing
each layer whose read path is plain transform composition (Store's own
__getitem__/__iter__), until it reaches a store that really implements the
bulk-read protocol; the stream is then re-transformed by the crossed layers,
innermost first, so it lands in the same space as `store[k]`. A layer that
redefines __getitem__ or __iter__ (postget, filt_iter, cached_keys) cannot be
pushed onto a bulk stream, so the resolver raises NoBulkReadPath and the views
fall back to the generic per-key path: correct, just one round trip per key.
Containment (`v in s.values()`) follows the same rule in the ingoing
direction, and falls back when a value transform declares no inverse -- that
case previously reached pymongo with a non-document filter and raised.

Everything stays local to mongodol; dol is untouched. The change is opt-out
via the documented `disable_bulk_read` class decorator, for classes that
inherit bulk-read methods no longer matching their own __getitem__.

Also in this change, all surfaced by the new invariant tests:
- MongoBaseStore keeps working (mongodol.trans.wrap_kvs) and now forwards
  through the resolver, so it composes with plain wrap_kvs layers too.
- MongoCollectionMultipleDocs{Reader,Persister} were entirely broken:
  __getitem__ always raised (an obj_of_data-shaped function was passed as a
  postget) and __setitem__ always raised (a stale `_mgc` attribute, plus a
  Mapping treated as a collection of docs). Fixed, and declared
  bulk-unfaithful since their values are *lists* of docs.
- Dropped tests/not_working.py: an uncollected TDD placeholder for exactly
  this issue, now covered by tests/views_test.py.

Known gap, deliberately left and pinned with a strict xfail: with no
getitem_projection, iter_items pops the key fields out of the value, so
items() values lack '_id' while store[k] has it. Fixing that changes
behaviour tests/int_tests/base_int_test.py explicitly encodes.

Claude-Session: https://claude.ai/code/session_01Kug7UUbVeCQgruvNXUq63c
@thorwhalen
thorwhalen merged commit 6a41b99 into master Aug 4, 2026
10 checks passed
@thorwhalen
thorwhalen deleted the claude/mongodol-bulk-views branch August 4, 2026 13:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

store.values() not working with mongo stores, after a wrap_kvs!

1 participant